Libérez la puissance du traitement parallèle en JavaScript. Apprenez à gérer les Promises concurrentes avec Promise.all, allSettled, race, et any pour des applications plus rapides et robustes.
Maîtriser la Concurrence en JavaScript : Une Plongée en Profondeur dans le Traitement Parallèle des Promises
Dans le paysage du développement web moderne, la performance n'est pas une fonctionnalité ; c'est une exigence fondamentale. Les utilisateurs du monde entier s'attendent à ce que les applications soient rapides, réactives et fluides. Au cœur de ce défi de performance, en particulier en JavaScript, se trouve le concept de gestion efficace des opérations asynchrones. De la récupération de données depuis une API à la lecture d'un fichier ou à l'interrogation d'une base de données, de nombreuses tâches ne se terminent pas instantanément. La manière dont nous gérons ces périodes d'attente peut faire la différence entre une application lente et une expérience utilisateur merveilleusement fluide.
JavaScript, par sa nature, est un langage monothread (single-threaded). Cela signifie qu'il ne peut exécuter qu'un seul morceau de code à la fois. Cela peut sembler être une limitation, mais la boucle d'événements (event loop) de JavaScript et son modèle d'E/S non bloquantes lui permettent de gérer les tâches asynchrones avec une efficacité incroyable. La pierre angulaire moderne de ce modèle est la Promise (Promesse) — un objet représentant l'achèvement éventuel (ou l'échec) d'une opération asynchrone.
Cependant, le simple fait d'utiliser des Promises ou leur élégante syntaxe `async/await` ne garantit pas automatiquement des performances optimales. Un écueil courant pour les développeurs est de gérer plusieurs tâches asynchrones indépendantes de manière séquentielle, créant ainsi des goulots d'étranglement inutiles. C'est là que le traitement concurrent des promises entre en jeu. En lançant plusieurs opérations asynchrones en parallèle et en attendant collectivement leur achèvement, nous pouvons réduire considérablement le temps d'exécution total et construire des applications beaucoup plus efficaces.
Ce guide complet vous emmènera dans une plongée en profondeur dans le monde de la concurrence en JavaScript. Nous explorerons les outils intégrés directement dans le langage — `Promise.all()`, `Promise.allSettled()`, `Promise.race()`, et `Promise.any()` — pour vous aider à orchestrer des tâches parallèles comme un pro. Que vous soyez un développeur junior qui se familiarise avec l'asynchronisme ou un ingénieur chevronné cherchant à affiner ses patrons de conception, cet article vous dotera des connaissances nécessaires pour écrire du code JavaScript plus rapide, plus résilient et plus sophistiqué.
D'abord, une clarification rapide : Concurrence vs. Parallélisme
Avant de poursuivre, il est important de clarifier deux termes qui sont souvent utilisés de manière interchangeable mais qui ont des significations distinctes en informatique : la concurrence et le parallélisme.
- La concurrence est le concept de gestion de plusieurs tâches sur une période de temps. Il s'agit de traiter de nombreuses choses à la fois. Un système est concurrent s'il peut démarrer, exécuter et terminer plus d'une tâche sans attendre que la précédente soit terminée. Dans l'environnement monothread de JavaScript, la concurrence est réalisée via la boucle d'événements, qui permet au moteur de basculer entre les tâches. Pendant qu'une tâche de longue durée (comme une requête réseau) est en attente, le moteur peut travailler sur d'autres choses.
- Le parallélisme est le concept d'exécution de plusieurs tâches simultanément. Il s'agit de faire de nombreuses choses à la fois. Le véritable parallélisme nécessite un processeur multicœur, où différents threads peuvent s'exécuter sur différents cœurs exactement au même moment. Bien que les web workers permettent un véritable parallélisme en JavaScript côté navigateur, le modèle de concurrence principal dont nous discutons ici concerne le thread principal unique.
Pour les opérations liées aux E/S (comme les requêtes réseau), le modèle concurrent de JavaScript donne l'effet du parallélisme. Nous pouvons initier plusieurs requêtes à la fois. Pendant que le moteur JavaScript attend les réponses, il est libre d'effectuer d'autres tâches. Les opérations se déroulent 'en parallèle' du point de vue des ressources externes (serveurs, systèmes de fichiers). C'est ce modèle puissant que nous allons exploiter.
Le Piège Séquentiel : Un Anti-Modèle Courant
Commençons par identifier une erreur courante. Lorsque les développeurs apprennent `async/await`, la syntaxe est si propre qu'il est facile d'écrire du code qui semble synchrone mais qui est par inadvertance séquentiel et inefficace. Imaginez que vous ayez besoin de récupérer le profil d'un utilisateur, ses publications récentes et ses notifications pour construire un tableau de bord.
Une approche naïve pourrait ressembler à ceci :
Exemple : La Récupération Séquentielle Inefficace
async function fetchDashboardDataSequentially(userId) {
console.time('sequentialFetch');
console.log('Récupération du profil utilisateur...');
const userProfile = await fetchUserProfile(userId); // Attend ici
console.log('Récupération des publications de l\'utilisateur...');
const userPosts = await fetchUserPosts(userId); // Attend ici
console.log('Récupération des notifications de l\'utilisateur...');
const userNotifications = await fetchUserNotifications(userId); // Attend ici
console.timeEnd('sequentialFetch');
return { userProfile, userPosts, userNotifications };
}
// Imaginez que ces fonctions prennent du temps à se résoudre
// fetchUserProfile -> 500ms
// fetchUserPosts -> 800ms
// fetchUserNotifications -> 1000ms
Qu'est-ce qui ne va pas dans ce tableau ? Chaque mot-clé `await` met en pause l'exécution de la fonction `fetchDashboardDataSequentially` jusqu'à ce que la promesse soit résolue. La requête pour `userPosts` ne démarre même pas tant que la requête `userProfile` n'est pas entièrement terminée. La requête pour `userNotifications` ne démarre pas tant que `userPosts` n'est pas de retour. Ces trois requêtes réseau sont indépendantes les unes des autres ; il n'y a aucune raison d'attendre ! Le temps total pris sera la somme de tous les temps individuels :
Temps Total ≈ 500ms + 800ms + 1000ms = 2300ms
C'est un énorme goulot d'étranglement pour les performances. Nous pouvons faire beaucoup, beaucoup mieux.
Libérer la Performance : La Puissance de l'Exécution Concurrente
La solution est d'initier toutes les opérations asynchrones en même temps, sans les attendre immédiatement. Cela leur permet de s'exécuter de manière concurrente. Nous pouvons stocker les objets Promise en attente dans des variables, puis utiliser un combinateur de Promises pour attendre qu'ils soient tous terminés.
Exemple : La Récupération Concurrente Efficace
async function fetchDashboardDataConcurrently(userId) {
console.time('concurrentFetch');
console.log('Lancement de toutes les récupérations en même temps...');
const profilePromise = fetchUserProfile(userId);
const postsPromise = fetchUserPosts(userId);
const notificationsPromise = fetchUserNotifications(userId);
// Maintenant, nous attendons qu'elles se terminent toutes
const [userProfile, userPosts, userNotifications] = await Promise.all([
profilePromise,
postsPromise,
notificationsPromise
]);
console.timeEnd('concurrentFetch');
return { userProfile, userPosts, userNotifications };
}
Dans cette version, nous appelons les trois fonctions de récupération sans `await`. Cela démarre immédiatement les trois requêtes réseau. Le moteur JavaScript les transmet à l'environnement sous-jacent (le navigateur ou Node.js) et reçoit en retour trois Promises en attente. Ensuite, `Promise.all()` est utilisé pour attendre que ces trois promesses soient toutes résolues. Le temps total pris est maintenant déterminé par l'opération la plus longue, et non par la somme.
Temps Total ≈ max(500ms, 800ms, 1000ms) = 1000ms
Nous venons de réduire notre temps de récupération de données de plus de moitié ! C'est le principe fondamental du traitement parallèle des promises. Maintenant, explorons les outils puissants que JavaScript fournit pour orchestrer ces tâches concurrentes.
La Boîte à Outils des Combinateurs de Promises : `all`, `allSettled`, `race`, et `any`
JavaScript fournit quatre méthodes statiques sur l'objet `Promise`, connues sous le nom de combinateurs de promises. Chacune prend un itérable (comme un tableau) de promesses et retourne une nouvelle promesse unique. Le comportement de cette nouvelle promesse dépend du combinateur que vous utilisez.
1. `Promise.all()` : L'Approche du Tout ou Rien
`Promise.all()` est l'outil parfait lorsque vous avez un groupe de tâches qui sont toutes essentielles pour l'étape suivante. Il représente la condition logique "ET" : la Tâche 1 ET la Tâche 2 ET la Tâche 3 doivent toutes réussir.
- Entrée : Un itérable de promesses.
- Comportement : Il retourne une promesse unique qui est accomplie lorsque toutes les promesses en entrée ont été accomplies. La valeur d'accomplissement est un tableau des résultats des promesses en entrée, dans le même ordre.
- Mode d'échec : Il rejette immédiatement dès que l'une des promesses en entrée est rejetée. La raison du rejet est la raison de la première promesse qui a été rejetée. On parle souvent de comportement "fail-fast" (échec rapide).
Cas d'Utilisation : Agrégation de Données Critiques
Notre exemple de tableau de bord est un cas d'utilisation parfait. Si vous не pouvez pas charger le profil de l'utilisateur, afficher ses publications et ses notifications n'a peut-être pas de sens. L'ensemble du composant dépend de la disponibilité des trois points de données.
// Fonction utilitaire pour simuler des appels API
const mockApiCall = (value, delay, shouldFail = false) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error(`L'appel API a échoué pour : ${value}`));
} else {
console.log(`Résolu : ${value}`);
resolve({ data: value });
}
}, delay);
});
};
async function loadCriticalData() {
console.log('Utilisation de Promise.all pour les données critiques...');
try {
const [profile, settings, permissions] = await Promise.all([
mockApiCall('userProfile', 400),
mockApiCall('userSettings', 700),
mockApiCall('userPermissions', 500)
]);
console.log('Toutes les données critiques ont été chargées avec succès !');
// Maintenant, afficher l'UI avec le profil, les paramètres et les permissions
} catch (error) {
console.error('Échec du chargement des données critiques :', error.message);
// Afficher un message d'erreur à l'utilisateur
}
}
// Que se passe-t-il si l'une échoue ?
async function loadCriticalDataWithFailure() {
console.log('\nDémonstration de l\'échec de Promise.all...');
try {
const results = await Promise.all([
mockApiCall('userProfile', 400),
mockApiCall('userSettings', 700, true), // Celle-ci va échouer
mockApiCall('userPermissions', 500)
]);
} catch (error) {
console.error('Promise.all a été rejetée :', error.message);
// Note : Les appels 'userProfile' et 'userPermissions' ont peut-être abouti,
// mais leurs résultats sont perdus car toute l'opération a échoué.
}
}
loadCriticalData();
// Après un délai, appeler l'exemple d'échec
setTimeout(loadCriticalDataWithFailure, 2000);
Écueil de `Promise.all()`
Le principal écueil est sa nature fail-fast. Si vous récupérez des données pour dix widgets différents et indépendants sur une page, et qu'une API échoue, `Promise.all()` sera rejetée, et vous perdrez les résultats des neuf autres appels réussis. C'est là que notre prochain combinateur brille.
2. `Promise.allSettled()` : Le Collecteur Résilient
Introduit dans ES2020, `Promise.allSettled()` a changé la donne en matière de résilience. Il est conçu pour les situations où vous voulez connaître le résultat de chaque promesse, qu'elle ait réussi ou échoué. Il ne rejette jamais.
- Entrée : Un itérable de promesses.
- Comportement : Il retourne une promesse unique qui est toujours accomplie. Elle est accomplie une fois que toutes les promesses en entrée sont terminées (settled), c'est-à-dire accomplies ou rejetées. La valeur d'accomplissement est un tableau d'objets, chacun décrivant le résultat d'une promesse.
- Format du résultat : Chaque objet de résultat a une propriété `status`.
- Si accomplie : `{ status: 'fulfilled', value: leResultat }`
- Si rejetée : `{ status: 'rejected', reason: lErreur }`
Cas d'Utilisation : Opérations Indépendantes Non Critiques
Imaginez une page qui affiche plusieurs composants indépendants : un widget météo, un fil d'actualités et un téléscripteur boursier. Si l'API du fil d'actualités échoue, vous voulez quand même afficher les informations météo et boursières. `Promise.allSettled()` est parfait pour cela.
async function loadDashboardWidgets() {
console.log('\nUtilisation de Promise.allSettled pour des widgets indépendants...');
const results = await Promise.allSettled([
mockApiCall('Données Météo', 600),
mockApiCall('Fil d\'actualités', 1200, true), // Cette API est en panne
mockApiCall('Téléscripteur Boursier', 800)
]);
console.log('Toutes les promesses sont terminées. Traitement des résultats...');
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Widget ${index} chargé avec succès avec les données :`, result.value.data);
// Afficher ce widget dans l'UI
} else {
console.error(`Le chargement du widget ${index} a échoué :`, result.reason.message);
// Afficher un état d'erreur spécifique pour ce widget
}
});
}
loadDashboardWidgets();
Avec `Promise.allSettled()`, votre application devient beaucoup plus robuste. Un point de défaillance unique ne provoque pas une cascade qui fait tomber toute l'interface utilisateur. Vous pouvez gérer chaque résultat avec élégance.
3. `Promise.race()` : Le Premier à la Ligne d'Arrivée
`Promise.race()` fait exactement ce que son nom suggère. Il met un groupe de promesses en compétition les unes contre les autres et déclare un vainqueur dès que la première franchit la ligne d'arrivée, qu'il s'agisse d'un succès ou d'un échec.
- Entrée : Un itérable de promesses.
- Comportement : Il retourne une promesse unique qui se termine (s'accomplit ou est rejetée) dès que la première des promesses en entrée se termine. La valeur d'accomplissement ou la raison du rejet de la promesse retournée sera celle de la promesse "gagnante".
- Note importante : Les autres promesses ne sont pas annulées. Elles continueront de s'exécuter en arrière-plan, et leurs résultats seront simplement ignorés par le contexte de `Promise.race()`.
Cas d'Utilisation : Implémenter un Timeout (délai d'attente)
Le cas d'utilisation le plus courant et pratique pour `Promise.race()` est d'imposer un délai d'attente (timeout) à une opération asynchrone. Vous pouvez faire "courir" votre opération principale contre une promesse de `setTimeout`. Si votre opération prend trop de temps, la promesse de timeout se terminera en premier, et vous pourrez la gérer comme une erreur.
function createTimeout(delay) {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`L'opération a expiré après ${delay}ms`));
}, delay);
});
}
async function fetchDataWithTimeout() {
console.log('\nUtilisation de Promise.race pour un timeout...');
try {
const result = await Promise.race([
mockApiCall('quelques données critiques', 2000), // Cela prendra trop de temps
createTimeout(1500) // Celui-ci gagnera la course
]);
console.log('Données récupérées avec succès :', result.data);
} catch (error) {
console.error(error.message);
}
}
fetchDataWithTimeout();
Autre Cas d'Utilisation : Points d'Accès Redondants
Vous pourriez également utiliser `Promise.race()` pour interroger plusieurs serveurs redondants pour la même ressource et prendre la réponse du serveur le plus rapide. Cependant, c'est risqué car si le serveur le plus rapide renvoie une erreur (par exemple, un code de statut 500), `Promise.race()` sera rejetée immédiatement, même si un serveur légèrement plus lent aurait retourné une réponse réussie. Cela nous amène à notre dernier combinateur, plus adapté à ce scénario.
4. `Promise.any()` : Le Premier à Réussir
Introduit dans ES2021, `Promise.any()` est comme une version plus optimiste de `Promise.race()`. Il attend également que la première promesse se termine, mais il recherche spécifiquement la première à s'accomplir.
- Entrée : Un itérable de promesses.
- Comportement : Il retourne une promesse unique qui s'accomplit dès que l'une des promesses en entrée s'accomplit. La valeur d'accomplissement est la valeur de la première promesse qui s'est accomplie.
- Mode d'échec : Il ne rejette que si toutes les promesses en entrée sont rejetées. La raison du rejet est un objet spécial `AggregateError`, qui contient une propriété `errors` — un tableau de toutes les raisons de rejet individuelles.
Cas d'Utilisation : Récupération depuis des Sources Redondantes
C'est l'outil parfait pour récupérer une ressource à partir de plusieurs sources, comme des serveurs primaires et de secours ou plusieurs réseaux de diffusion de contenu (CDN). Vous ne vous souciez que d'obtenir une réponse réussie le plus rapidement possible.
async function fetchResourceFromMirrors() {
console.log('\nUtilisation de Promise.any pour trouver la source réussie la plus rapide...');
try {
const resource = await Promise.any([
mockApiCall('CDN Primaire', 800, true), // Échoue rapidement
mockApiCall('Miroir Européen', 1200), // Plus lent mais réussira
mockApiCall('Miroir Asiatique', 1100) // Réussit aussi, mais est plus lent que le miroir européen
]);
console.log('Ressource récupérée avec succès depuis un miroir :', resource.data);
} catch (error) {
if (error instanceof AggregateError) {
console.error('Tous les miroirs ont échoué à fournir la ressource.');
// Vous pouvez inspecter les erreurs individuelles :
error.errors.forEach(err => console.log('- ' + err.message));
}
}
}
fetchResourceFromMirrors();
Dans cet exemple, `Promise.any()` ignorera l'échec rapide du CDN primaire et attendra que le miroir européen s'accomplisse, moment auquel il se résoudra avec ces données et ignorera effectivement le résultat du miroir asiatique.
Choisir le Bon Outil pour la Tâche : Un Guide Rapide
Avec quatre options puissantes, comment décider laquelle utiliser ? Voici un cadre de décision simple :
- Ai-je besoin des résultats de TOUTES les promesses, et est-ce une catastrophe si L'UNE d'entre elles échoue ?
UtilisezPromise.all(). C'est pour les scénarios fortement couplés, de type tout ou rien. - Ai-je besoin de connaître le résultat de TOUTES les promesses, qu'elles réussissent ou échouent ?
UtilisezPromise.allSettled(). C'est pour gérer plusieurs tâches indépendantes où vous voulez traiter chaque résultat et maintenir la résilience de l'application. - Est-ce que je me soucie uniquement de la toute première promesse à se terminer, que ce soit un succès ou un échec ?
UtilisezPromise.race(). C'est principalement pour implémenter des timeouts ou d'autres conditions de course où seul le premier résultat (de quelque nature que ce soit) compte. - Est-ce que je me soucie uniquement de la première promesse à RÉUSSIR, et puis-je ignorer celles qui échouent ?
UtilisezPromise.any(). C'est pour les scénarios impliquant de la redondance, comme essayer plusieurs points d'accès pour la même ressource.
Patrons Avancés et Considérations du Monde Réel
Bien que les combinateurs de promises soient incroyablement puissants, le développement professionnel nécessite souvent un peu plus de nuance.
Limitation de la Concurrence et Throttling
Que se passe-t-il si vous avez un tableau de 1 000 identifiants et que vous voulez récupérer les données pour chacun d'eux ? Si vous passez naïvement les 1 000 appels générant des promesses dans `Promise.all()`, vous déclencherez instantanément 1 000 requêtes réseau. Cela peut avoir plusieurs conséquences négatives :
- Surcharge du serveur : Vous pourriez submerger le serveur que vous interrogez, entraînant des erreurs ou une dégradation des performances pour tous les utilisateurs.
- Limitation de débit (Rate Limiting) : La plupart des API publiques ont des limites de débit. Vous atteindrez probablement votre limite et recevrez des erreurs `429 Too Many Requests`.
- Ressources client : Le client (navigateur ou serveur) pourrait avoir du mal à gérer autant de connexions réseau ouvertes simultanément.
La solution est de limiter la concurrence en traitant les promesses par lots. Bien que vous puissiez écrire votre propre logique pour cela, des bibliothèques matures comme `p-limit` ou `async-pool` gèrent cela avec élégance. Voici un exemple conceptuel de la manière dont vous pourriez l'aborder manuellement :
async function processInBatches(items, batchSize, processingFn) {
let results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
console.log(`Traitement du lot commençant à l'index ${i}...`);
const batchPromises = batch.map(processingFn);
const batchResults = await Promise.allSettled(batchPromises);
results = results.concat(batchResults);
}
return results;
}
// Exemple d'utilisation :
const userIds = Array.from({ length: 20 }, (_, i) => i + 1);
// Nous allons traiter 20 utilisateurs par lots de 5
processInBatches(userIds, 5, id => mockApiCall(`user_${id}`, Math.random() * 1000))
.then(allResults => {
console.log('\nTraitement par lot terminé.');
const successful = allResults.filter(r => r.status === 'fulfilled').length;
const failed = allResults.filter(r => r.status === 'rejected').length;
console.log(`Résultats totaux : ${allResults.length}, Réussis : ${successful}, Échoués : ${failed}`);
});
Une Note sur l'Annulation
Un défi de longue date avec les Promises natives est qu'elles ne sont pas annulables. Une fois que vous créez une promesse, elle s'exécutera jusqu'à son terme. Bien que `Promise.race` puisse vous aider à ignorer un résultat lent, l'opération sous-jacente continue de consommer des ressources. Pour les requêtes réseau, la solution moderne est l'API `AbortController`, qui vous permet de signaler à une requête `fetch` qu'elle doit être annulée. L'intégration de `AbortController` avec les combinateurs de promises peut fournir un moyen robuste de gérer et de nettoyer les tâches concurrentes de longue durée.
Conclusion : De la Pensée Séquentielle à la Pensée Concurrente
Maîtriser le JavaScript asynchrone est un parcours. Il commence par la compréhension de la boucle d'événements monothread, progresse vers l'utilisation des Promises et de `async/await` pour plus de clarté, et culmine dans une pensée concurrente pour maximiser les performances. Passer d'un état d'esprit séquentiel avec `await` à une approche privilégiant le parallélisme est l'un des changements les plus marquants qu'un développeur puisse faire pour améliorer la réactivité d'une application.
En exploitant les combinateurs de promises intégrés, vous êtes équipé pour gérer une grande variété de scénarios du monde réel avec élégance et précision :
- Utilisez `Promise.all()` pour les dépendances de données critiques, de type tout ou rien.
- Faites confiance à `Promise.allSettled()` pour construire des interfaces utilisateur résilientes avec des composants indépendants.
- Employez `Promise.race()` pour imposer des contraintes de temps et éviter les attentes indéfinies.
- Choisissez `Promise.any()` pour créer des systèmes rapides et tolérants aux pannes avec des sources de données redondantes.
La prochaine fois que vous vous retrouverez à écrire plusieurs instructions `await` à la suite, faites une pause et demandez-vous : "Ces opérations sont-elles vraiment dépendantes les unes des autres ?" Si la réponse est non, vous avez une excellente occasion de refactoriser votre code pour la concurrence. Commencez à initier vos promesses ensemble, choisissez le bon combinateur pour votre logique, et regardez les performances de votre application s'envoler.